Skip to content

Enforce NONMODIFIABLE and TRUSTED NVM policy on keystore key commit - #492

Open
yosuke-wolfssl wants to merge 1 commit into
wolfSSL:mainfrom
yosuke-wolfssl:fix/f_4225
Open

Enforce NONMODIFIABLE and TRUSTED NVM policy on keystore key commit#492
yosuke-wolfssl wants to merge 1 commit into
wolfSSL:mainfrom
yosuke-wolfssl:fix/f_4225

Conversation

@yosuke-wolfssl

@yosuke-wolfssl yosuke-wolfssl commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Problem

wh_Server_KeystoreCommitKey writes the cached key to NVM through the
unchecked wh_Nvm_AddObjectWithReclaim, so nothing consulted the stored
object's flags. A client could overwrite an existing NONMODIFIABLE or
TRUSTED NVM object — including a trusted KEK — by caching a key under its id
and committing. Closes f-4225.

Fix (src/wh_server_keystore.c)

Added a WH_KS_OP_COMMIT branch to _KeystoreCheckPolicy that reads the
stored object's metadata and denies the overwrite:

  • Stored flags, not cached flags — an unchecked cache path
    (unwrap-and-cache, SHE) cannot launder the policy by populating a slot whose
    flags do not reflect the stored object.
  • NONMODIFIABLE | TRUSTED — the same pair as wh_Nvm_AddObjectChecked,
    the correct model for an add-shaped operation. NONDESTROYABLE gates
    destroy, not overwrite.
  • Independent of cache residency — the verdict comes from nvmMeta whether
    or not the cache slot survived, so a denied commit always reports
    WH_ERROR_ACCESS instead of the WH_ERROR_NOTFOUND raised by a missing slot.
  • Unreadable flags fail closed — a backend that cannot report metadata
    cannot be policed, so the commit is denied rather than blind.

Client-visible behavior change: re-committing a cached key whose stored
object is NONMODIFIABLE now returns WH_ERROR_ACCESS where a byte-identical
rewrite previously returned WH_ERROR_OK. This makes commit consistent with
every other immutable-write path — _NvmCheckPolicy (WH_NVM_OP_ADD) already
refuses a no-op rewrite, and commit was the only exception. The contract is now
documented on wh_Server_KeystoreCommitKeyChecked
(wolfhsm/wh_server_keystore.h): a client retrying after a lost response must
treat WH_ERROR_ACCESS as "already committed". Revoke is unaffected; it keeps
its own "already revoked and committed" short-circuit.

Tests

Added to test-refactor/client-server/wh_test_crypto_keystore.c, driven only
by wh_Client_*:

  • _whTest_NonModifiableCommit — first commit succeeds, repeat commit returns
    WH_ERROR_ACCESS, the denial still holds once the slot is evicted, and the
    stored bytes and label survive the denial. Gated behind
    WOLFHSM_CFG_TEST_ALLOW_PERSISTENT_NVM_ARTIFACTS, since a committed
    NONMODIFIABLE object cannot be erased.
  • _whTest_ModifiableRecommit — ungated; a key without the flag still commits
    repeatedly.

Making that gate runnable. test-refactor could not build with the macro
defined at all: wh_test_crypto_keypolicy.c calls WH_CLIENT_DEVID(client)
inside a helper that takes no client context (pre-existing on main, so both
gated suites were dead code). Threading the context into
whTest_RevocationTryAESEncrypt fixes it, and a new trailing step in
.github/workflows/build-and-test-refactor.yml builds and runs with the macro
defined. That revives this PR's deny-path test and the keypolicy AES-CBC
revocation test, dormant since 606866e.

Verification

Config Gate off Gate on
default 45 passed, 26 skipped, 0 failed of 71 45 / 26 / 0
DMA=1 ASAN=1 49 passed, 22 skipped, 0 failed of 71 49 / 22 / 0
  • Identical counts either way; clean under -std=c90 -Werror -Wall -Wextra,
    ASan clean. Legacy test/ suite exits 0.
  • Negative control: with only the WH_KS_OP_COMMIT branch reverted, the gated
    run fails — Non-modifiable key was re-committed unexpectedly: 0.

@yosuke-wolfssl yosuke-wolfssl self-assigned this Jul 26, 2026
Copilot AI review requested due to automatic review settings July 26, 2026 23:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request closes a defense-in-depth gap in the server keystore “gate” by ensuring WH_KEY_COMMIT cannot overwrite existing NVM objects that are marked NONMODIFIABLE or TRUSTED. The policy enforcement is implemented centrally in the keystore policy checker (rather than in the lower-level commit function), and is validated via a new server-side test suite.

Changes:

  • Add a WH_KS_OP_COMMIT policy branch that consults the stored NVM object’s flags and denies overwrite when NONMODIFIABLE or TRUSTED.
  • Register and add a new server test (whTest_KeystoreCommitPolicy) covering overwrite-denial, first-commit allowance, normal commit round-trip, and the “uncached commit returns NOTFOUND” guard.
  • Extend the server test registry to include the new test.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
src/wh_server_keystore.c Enforces NONMODIFIABLE/TRUSTED overwrite denial for commit by checking stored NVM metadata when a cache slot is being committed.
test-refactor/server/wh_test_keystore_policy.c Adds targeted server-side tests validating commit policy behavior and ensuring stored bytes/flags remain unchanged on denied overwrite.
test-refactor/wh_test_list.c Registers the new keystore commit policy test in the server test group.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #492

Scan targets checked: wolfhsm-core-bugs, wolfhsm-src

No new issues found in the changed files. ✅

@Frauschi Frauschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐺 Skoll Code Review

Overall recommendation: CONDITIONAL
Findings: 6 total — 3 posted, 3 skipped

Posted findings

  • [Low] Repeat/idempotent commit of a NONMODIFIABLE key now fails with WH_ERROR_ACCESS (undeclared client-visible behavior change)src/wh_server_keystore.c:247-263
  • [Info] The TRUSTED half of the new check is only reachable via unchecked cache paths; comment does not say sosrc/wh_server_keystore.c:248-257
  • [Info] Commit-overwrite does not consider NONDESTROYABLE, unlike the evict gatesrc/wh_server_keystore.c:247-263
Skipped findings
  • [Low] New access-control branch has no test coverage at any level, and the stated rationale for omitting tests does not hold
  • [Info] Re-committing an already-committed NONMODIFIABLE key now returns WH_ERROR_ACCESS, breaking idempotent commit retries
  • [Info] Commit path now hard-depends on the optional NVM GetMetadata callback

Review generated by Skoll via Claude/Codex

Comment thread src/wh_server_keystore.c
Comment thread src/wh_server_keystore.c Outdated
Comment thread src/wh_server_keystore.c
@yosuke-wolfssl

Copy link
Copy Markdown
Contributor Author

Hello @Frauschi ,
I fixed this based on your feedbacks.
Could you review it again ?

@Frauschi Frauschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐺 Skoll Code Review

Overall recommendation: CONDITIONAL
Findings: 6 total — 2 posted, 4 skipped

Posted findings

  • [Low] New test leaves an undeletable NONMODIFIABLE NVM object, bypassing the repo's WOLFHSM_CFG_TEST_ALLOW_PERSISTENT_NVM_ARTIFACTS conventiontest-refactor/client-server/wh_test_crypto_keystore.c:909-911
  • [Low] Commit denial returns WH_ERROR_NOTFOUND instead of WH_ERROR_ACCESS when the cache slot is gonesrc/wh_server_keystore.c:247-263
Skipped findings
  • [Medium] New keystore test permanently leaks an unerasable NVM object into the shared test fixture
  • [Low] wh_Client_KeyCommit is no longer idempotent for NONMODIFIABLE keys, making commit retries a hard failure
  • [Info] Fail-closed unreadable-metadata branch in the new commit policy has no test coverage and returns a non-ACCESS error code
  • [Info] Revoke path still writes cached bytes to NVM unchecked, contradicting the new comment on the shared switch case

Review generated by Skoll via Claude/Codex

Comment thread test-refactor/client-server/wh_test_crypto_keystore.c
Comment thread src/wh_server_keystore.c

@Frauschi Frauschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐺 Skoll Code Review

Overall recommendation: APPROVE
Findings: 10 total — 3 posted, 7 skipped

Posted findings

  • [Medium] Fix guards only one of the two client-reachable commit paths; LMS/XMSS DMA keygen still commits uncheckedsrc/wh_server_crypto.c:7816
  • [Medium] Commit is no longer idempotent for NONMODIFIABLE keys, and the contract is undocumentedsrc/wh_server_keystore.c:247
  • [Medium] New NONMODIFIABLE commit regression test never compiles or runs in any buildable configurationtest-refactor/client-server/wh_test_crypto_keystore.c:842
Skipped findings
  • [Medium] New commit-denial test is compiled out in every supported configuration
  • [Low] Discarded evict return lets the uncached-denial test pass for the wrong reason
  • [Low] Fail-closed comment does not cover the server->nvm == NULL path
  • [Low] COMMIT branch duplicates the NVM metadata lookup already performed above the switch
  • [Low] Discarded evict return leaves Test 4's uncached-commit premise unverified
  • [Info] Identical 32-byte key literal duplicated across the two new tests
  • [Info] _KeystoreCheckPolicy now carries two divergent notions of "the key's flags"

Review generated by Skoll via Claude/Codex

Comment thread src/wh_server_keystore.c
Comment thread test-refactor/client-server/wh_test_crypto_keystore.c

@Frauschi Frauschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐺 Skoll Code Review

Overall recommendation: REQUEST_CHANGES
Findings: 10 total — 5 posted, 5 skipped

Posted findings

  • [Medium] New CI step's CFLAGS_EXTRA override silently disables -Werror -Wall -Wextra for the only job that compiles the gated tests.github/workflows/build-and-test-refactor.yml:133-136
  • [Medium] No test covers the TRUSTED half of the new commit mask - the actual KEK-overwrite scenariosrc/wh_server_keystore.c:261-265
  • [Medium] Deny-path test could live in test-refactor/server/ and avoid the persistence gate and the new CI job entirelytest-refactor/client-server/wh_test_crypto_keystore.c:842-931
  • [Medium] Client-facing header not updated with the new non-idempotent commit contractwolfhsm/wh_server_keystore.h:162-179
  • [Low] New WH_ERROR_NOTFOUND doc on wh_Server_KeystoreCommitKeyChecked is inaccurate, and the retry contract it documents is incompletewolfhsm/wh_server_keystore.h:171-178
Skipped findings
  • [Low] Gated CI job permanently consumes NVM object slots out of a budget of 30
  • [Low] WH_KS_OP_REVOKE keeps the unconditional allow the COMMIT branch was added to close, under a new comment asserting it is safe
  • [Info] Comment says "deny" but the code propagates the backend error code
  • [Info] Header doc overstates that the verdict never comes from cache flags
  • [Info] New gated test permanently consumes an NVM slot mid-run, and the TRUSTED half of the new check is untested

Review generated by Skoll via Claude/Codex

Comment thread .github/workflows/build-and-test-refactor.yml Outdated
Comment thread src/wh_server_keystore.c
Comment thread test-refactor/client-server/wh_test_crypto_keystore.c
Comment thread wolfhsm/wh_server_keystore.h
Comment thread wolfhsm/wh_server_keystore.h
@yosuke-wolfssl

Copy link
Copy Markdown
Contributor Author

Hello @Frauschi ,
Thank you for reviewing again. I fixed the issues you mentioned.
Please check my comments above.

@Frauschi Frauschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐺 Skoll Code Review

Overall recommendation: APPROVE
Findings: 5 total — 3 posted, 2 skipped

Posted findings

  • [Medium] Revoke still launders cached flags into NVM, contradicting the new "Revocation only tightens policy" commentsrc/wh_server_keystore.c:269-271
  • [Low] Suite header comment and README not updated for the two new teststest-refactor/client-server/wh_test_crypto_keystore.c:19-30
  • [Info] New CI step covers only the ASAN configuration.github/workflows/build-and-test-refactor.yml:159-162
Skipped findings
  • [Medium] NONMODIFIABLE deny path only runs in the new opt-in CI job
  • [Low] Commit of a cached key now requires a working GetMetadata callback

Review generated by Skoll via Claude/Codex

Comment thread src/wh_server_keystore.c
Comment thread test-refactor/client-server/wh_test_crypto_keystore.c
Comment thread .github/workflows/build-and-test-refactor.yml Outdated

@Frauschi Frauschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐺 Skoll Code Review

Overall recommendation: REQUEST_CHANGES
Findings: 6 total — 3 posted, 3 skipped

Posted findings

  • [High] Revoke still overwrites a stored TRUSTED/NONMODIFIABLE object, contradicting the new comment and leaving f-4225 half-opensrc/wh_server_keystore.c:269-273 (comment), 1435-1440 (write)
  • [Medium] Uncached-commit assertion can silently degrade into a duplicate of the cached casetest-refactor/client-server/wh_test_crypto_keystore.c:924-932
  • [Medium] Fail-closed branch on unreadable NVM metadata is untestedsrc/wh_server_keystore.c:252-261
Skipped findings
  • [Low] README says gated suites "report SKIPPED", but the new sub-tests vanish silently
  • [Low] New header contract ("caller must hold the NVM lock") is not followed by the new test added in the same PR
  • [Low] 32-byte key literal and label setup duplicated verbatim between the two new client tests

Review generated by Skoll via Claude/Codex

Comment thread src/wh_server_keystore.c
}
break;

case WH_KS_OP_REVOKE:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [High] Revoke still overwrites a stored TRUSTED/NONMODIFIABLE object, contradicting the new comment and leaving f-4225 half-open
🚫 BLOCK bug

The diff adds a comment on the WH_KS_OP_REVOKE case asserting _revokeKey only sets NONMODIFIABLE and clears usage flags, so revocation never grants new access. That claim is not accurate for the exact threat model the PR is fixing. wh_Server_KeystoreRevokeKey writes the cache buffer over the stored object (wh_Nvm_AddObjectWithReclaim(server->nvm, cacheMeta, cacheMeta->len, cacheBuf)), and its policy check still reads the cached flags — the same laundering the new WH_KS_OP_COMMIT branch was added to defeat. Setting up the identical pairing the PR's own new test _whTest_NvmPolicyCommitTrustedDenied builds (provision a TRUSTED KEK via wh_Nvm_AddObject, then wh_Server_KeystoreCacheKey forged bytes under the same id with TRUSTED cleared) and calling wh_Server_KeystoreRevokeKey instead of commit, I measured on this branch: revoke rc=0, stored byte 0 became 0xFF (the forged bytes, was 0x40), stored flags became 0x0001 (NONMODIFIABLE — TRUSTED was dropped). So a client can still destroy and replace a trusted KEK's stored material through WH_KEY_REVOKE, and the resulting object is NONMODIFIABLE so wh_Nvm_DestroyObjectsChecked will not let it be repaired. The cache-side reachability is the same one the PR cites for commit: _HandleKeyUnwrapAndCacheRequest calls the unchecked wh_Server_KeystoreCacheKey with a blob-supplied id (src/wh_server_keystore.c:2452), and WH_KEY_REVOKE (src/wh_server_keystore.c:3281) is client-reachable. The revoke code itself is pre-existing, but the diff introduces a comment asserting the property holds, and the PR is filed as closing f-4225 for exactly this overwrite class.

Suggestion:

Suggested change
case WH_KS_OP_REVOKE:
case WH_KS_OP_REVOKE:
/* Revoke rewrites the stored object from the cache slot, so it is
* an overwrite like commit: consult the stored flags, not the
* cached ones, or an unchecked cache path can launder them. */
if (!foundInNvm && (server->nvm != NULL)) {
ret = wh_Nvm_GetMetadata(server->nvm, keyId, &nvmMeta);
if (ret == WH_ERROR_OK) {
foundInNvm = 1;
}
else if (ret != WH_ERROR_NOTFOUND) {
return ret;
}
}
if (foundInNvm && (nvmMeta.flags & WH_NVM_FLAGS_TRUSTED)) {
return WH_ERROR_ACCESS;
}
break;

Recommendation: Gate WH_KS_OP_REVOKE on the stored object's WH_NVM_FLAGS_TRUSTED the same way commit now is (revoking a TRUSTED KEK is never a legitimate client operation). NONMODIFIABLE must stay permitted for revoke so an already-revoked key can be re-revoked, but TRUSTED can be denied without changing any normal flow. At minimum, correct the new comment and state the residual gap explicitly, and consider whether f-4225 should stay open. A server-side regression test mirroring _whTest_NvmPolicyCommitTrustedDenied but calling wh_Server_KeystoreRevokeKey reproduces this in a few lines.


/* The key cannot be erased: wh_Nvm_DestroyObjectsChecked refuses a
* NONMODIFIABLE object, so only the cache slot is reclaimed here. */
(void)wh_Client_KeyEvict(ctx, keyId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Medium] Uncached-commit assertion can silently degrade into a duplicate of the cached case
💡 SUGGEST test

Test 4 claims to prove the denial is independent of cache residency: the denial does not depend on cache residency. With no slot left, the stored flags still decide. But the eviction that is supposed to create the no-slot state discards its return with (void)wh_Client_KeyEvict(ctx, keyId);. The preceding wh_Client_KeyExport re-populates the cache via wh_Server_KeystoreReadKey, which only marks the freshened slot committed when wh_Server_KeystoreCacheKey succeeds (src/wh_server_keystore.c:1141). If that ever stops happening, WH_KS_OP_EVICT denies the evict for a NONMODIFIABLE key, the slot stays cached, and Test 4 still passes — but only as a re-run of Test 2, silently losing the coverage the comment claims. Test 3's evict is checked; this one should be too.

Suggestion:

Suggested change
(void)wh_Client_KeyEvict(ctx, keyId);
ret = wh_Client_KeyEvict(ctx, keyId);
if (ret != 0) {
WH_ERROR_PRINT("Failed to evict before uncached commit check: %d\n",
ret);
return ret;
}

Recommendation: Check the eviction return so the test fails loudly if the slot is still resident, rather than asserting a property it did not actually set up.

Comment thread src/wh_server_keystore.c
/* Stored flags decide, not cached, so an unchecked cache path
* cannot launder them; same pair as wh_Nvm_AddObjectChecked.
* Fetched here too, so the verdict ignores cache residency. */
if (!foundInNvm && (server->nvm != NULL)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Medium] Fail-closed branch on unreadable NVM metadata is untested
💡 SUGGEST test

The new WH_KS_OP_COMMIT branch has three outcomes: no stored object (allow), stored object with the flags (deny), and metadata unreadable (return ret, fail closed). The PR's tests cover the first two — _whTest_ModifiableRecommit / _whTest_NonModifiableCommit client-side and _whTest_NvmPolicyCommitTrustedDenied server-side — but nothing exercises the ret != WH_ERROR_NOTFOUND path. That branch is the one that decides a backend error denies rather than silently permits the write, which is the security-relevant default. The repo already has a fault-injection backend (test-refactor/posix/wh_test_flash_fault_inject.c), so this is testable without new infrastructure.

Recommendation: Add a server-side case that makes wh_Nvm_GetMetadata return a non-NOTFOUND error for a cached key and asserts the commit is refused rather than allowed, using the existing flash fault-injection harness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants